掌握树遍历的通用访问者模式。一份关于将算法与树结构分离的全面指南,以实现更灵活和可维护的代码。
深入探索通用访问者模式:解锁灵活的树遍历
在软件工程领域,我们经常会遇到以分层树状结构组织的数据。从编译器用来理解我们代码的抽象语法树 (AST),到驱动网络的文档对象模型 (DOM),甚至是简单的文件系统,树无处不在。处理这些结构时的一项基本任务是遍历:访问每个节点以执行某些操作。然而,挑战在于如何以清晰、可维护和可扩展的方式完成这项任务。
传统方法通常将操作逻辑直接嵌入到节点类中。这导致代码臃肿、紧密耦合,违反了核心软件设计原则。添加一个新操作,例如漂亮的打印机或验证器,会迫使您修改每个节点类,从而使系统变得脆弱且难以维护。
经典的访问者设计模式通过将算法与其操作的对象分离,提供了一个强大的解决方案。但即使是经典模式也有其局限性,特别是在可扩展性方面。这正是通用访问者模式大放异彩的地方,尤其是在应用于树遍历时。通过利用泛型、模板和变体等现代编程语言特性,我们可以创建一个高度灵活、可重用且功能强大的系统来处理任何树结构。
本次深入探讨将引导您从经典访问者模式走向一个复杂的通用实现。我们将探索:
- 经典访问者模式及其固有挑战的回顾。
- 演变为进一步解耦操作的通用方法。
- 通用树遍历访问者的详细分步实现。
- 将遍历逻辑与操作逻辑分离的深远益处。
- 此模式能够带来巨大价值的实际应用。
无论您是正在构建编译器、静态分析工具、UI 框架,还是任何依赖复杂数据结构的系统,掌握此模式都将提升您的架构思维和代码质量。
重温经典访问者模式
在我们能够欣赏通用演变之前,我们必须对其基础有扎实的理解。访问者模式,正如“四人帮”在其开创性著作《设计模式:可复用面向对象软件的元素》中所述,是一种行为型模式,它允许您向现有对象结构添加新操作,而无需修改这些结构。
它解决的问题
想象一下,您有一个简单的算术表达式树,由不同的节点类型组成,例如NumberNode(一个字面值)和AdditionNode(表示两个子表达式的相加)。您可能希望对这棵树执行几个不同的操作:
- 求值:计算表达式的最终数值结果。
- 美观打印:生成人类可读的字符串表示形式,例如“(5 + 3)”。
- 类型检查:验证操作对于所涉及的类型是否有效。
天真的方法是向基类`Node`添加诸如`evaluate()`、`print()`和`typeCheck()`之类的方法,并在每个具体节点类中覆盖它们。这会用不相关的逻辑使节点类变得臃肿。每当您发明一个新操作时,您都必须触及层次结构中的每一个节点类。这违反了开闭原则,该原则指出软件实体应该对扩展开放,对修改关闭。
经典解决方案:双重分派
访问者模式通过引入两个新的层次结构来解决这个问题:一个访问者层次结构和一个元素层次结构(我们的节点)。其奥秘在于一种称为双重分派的技术。
主要参与者是:
- 元素接口(例如,`Node`):定义一个`accept(Visitor v)`方法。
- 具体元素(例如,`NumberNode`,`AdditionNode`):实现`accept`方法。实现很简单:`visitor.visit(this);`。
- 访问者接口:为每个具体元素类型声明一个重载的`visit`方法。例如,`visit(NumberNode n)`和`visit(AdditionNode n)`。
- 具体访问者(例如,`EvaluationVisitor`,`PrintVisitor`):实现`visit`方法以执行特定操作。
其工作原理如下:您调用`node.accept(myVisitor)`。在`accept`内部,节点调用`myVisitor.visit(this)`。此时,编译器知道`this`的具体类型(例如,`AdditionNode`)和`myVisitor`的具体类型(例如,`EvaluationVisitor`)。因此,它可以分派到正确的`visit`方法:`EvaluationVisitor::visit(AdditionNode*)`。这种两步调用实现了单个虚函数调用无法实现的功能:根据两个不同对象的运行时类型解析正确的方法。
经典模式的局限性
尽管优雅,但经典访问者模式有一个显著的缺点,阻碍了其在演进系统中的使用:元素层次结构的僵化性。
The `Visitor`接口包含针对每种`ConcreteElement`类型的`visit`方法。如果您想添加一个新的节点类型——比如说,一个`MultiplicationNode`——您必须向基类`Visitor`接口添加一个新的`visit(MultiplicationNode n)`方法。这会迫使您更新系统中存在的每一个具体访问者类,以实现这个新方法。我们为添加新操作所解决的问题,现在在添加新元素类型时重新出现了。系统在操作方面是封闭的,但在元素方面却是完全开放的。
元素层次结构和访问者层次结构之间的这种循环依赖是寻求更灵活、通用解决方案的主要动机。
通用演进:一种更灵活的方法
经典模式的核心局限性在于访问者接口与具体元素类型之间的静态、编译时绑定。通用方法旨在打破这种绑定。其核心思想是将分派到正确处理逻辑的责任从重载方法的僵化接口中转移出来。
现代 C++,凭借其强大的模板元编程和标准库特性如`std::variant`,提供了一种极其简洁高效的实现方式。在 C# 或 Java 等语言中,也可以使用反射或泛型接口实现类似的方法,尽管可能会牺牲性能。
我们的目标是构建一个系统,其中:
- 添加新的节点类型是局部化的,并且不需要在所有现有访问者实现中引起一系列更改。
- 添加新操作仍然很简单,符合访问者模式的最初目标。
- 遍历逻辑本身(例如,前序、后序)可以泛型定义并用于任何操作。
第三点是我们的“树遍历类型实现”的关键。我们不仅将操作与数据结构分离,还将遍历行为与操作行为分离。
在 C++ 中实现树遍历的通用访问者
我们将使用现代 C++ (C++17 或更高版本) 来构建我们的通用访问者框架。`std::variant`、`std::unique_ptr` 和模板的组合为我们提供了一个类型安全、高效且高度表达性的解决方案。
步骤 1:定义树节点结构
首先,让我们定义我们的节点类型。我们不使用带有虚函数`accept`的传统继承层次结构,而是将节点定义为简单的结构体。然后我们将使用`std::variant`来创建一个可以容纳我们任何节点类型的和类型。
为了允许递归结构(一个节点包含其他节点的树),我们需要一个间接层。一个`Node`结构体将封装变体并为其子节点使用`std::unique_ptr`。
文件:`Nodes.h`
#include <memory> #include <variant> #include <vector> // 预声明主 Node 包装器 struct Node; // 将具体节点类型定义为简单的数据聚合体 struct NumberNode { double value; }; struct BinaryOpNode { enum class Operator { Add, Subtract, Multiply, Divide }; Operator op; std::unique_ptr<Node> left; std::unique_ptr<Node> right; }; struct UnaryOpNode { enum class Operator { Negate }; Operator op; std::unique_ptr<Node> operand; }; // 使用 std::variant 创建所有可能节点类型的和类型 using NodeVariant = std::variant<NumberNode, BinaryOpNode, UnaryOpNode>; // 封装变体的主 Node 结构体 struct Node { NodeVariant var; };
这种结构已经是一个巨大的改进。节点类型是简单的旧式数据结构体。它们对访问者或任何操作一无所知。要添加一个`FunctionCallNode`,您只需定义该结构体并将其添加到`NodeVariant`别名中。这是数据结构本身的一个单一修改点。
步骤 2:使用 `std::visit` 创建通用访问者
`std::visit`实用程序是此模式的基石。它接受一个可调用对象(如函数、lambda 表达式或带有`operator()`的对象)和一个`std::variant`,并根据变体中当前活动的类型调用可调用对象的正确重载。这就是我们的类型安全、编译时双重分派机制。
现在,访问者只是一个结构体,为变体中的每种类型都带有一个重载的`operator()`。
让我们创建一个简单的美观打印访问者来实际演示。
文件:`PrettyPrinter.h`
#include "Nodes.h" #include <string> #include <iostream> struct PrettyPrinter { // NumberNode 的重载 void operator()(const NumberNode& node) const { std::cout << node.value; } // UnaryOpNode 的重载 void operator()(const UnaryOpNode& node) const { std::cout << "(-"; std::visit(*this, node.operand->var); // 递归访问 std::cout << ")"; } // BinaryOpNode 的重载 void operator()(const BinaryOpNode& node) const { std::cout << "("; std::visit(*this, node.left->var); // 递归访问左侧 switch (node.op) { case BinaryOpNode::Operator::Add: std::cout << " + "; break; case BinaryOpNode::Operator::Subtract: std::cout << " - "; break; case BinaryOpNode::Operator::Multiply: std::cout << " * "; break; case BinaryOpNode::Operator::Divide: std::cout << " / "; break; } std::visit(*this, node.right->var); // 递归访问右侧 std::cout << ")"; } };
注意这里发生了什么。遍历逻辑(访问子节点)和操作逻辑(打印括号和运算符)在`PrettyPrinter`内部混合在一起。这是可行的,但我们可以做得更好。我们可以将做什么与如何做分离。
步骤 3:重头戏——通用树遍历访问者
现在,我们引入核心概念:一个可重用的`TreeWalker`,它封装了遍历策略。这个`TreeWalker`本身将是一个访问者,但它唯一的工作是遍历树。它将接受在遍历过程中特定点执行的其他函数(lambda 或函数对象)。
我们可以支持不同的策略,但一个常见且强大的策略是为“预访问”(在访问子节点之前)和“后访问”(在访问子节点之后)提供钩子。这直接映射到前序和后序遍历动作。
文件:`TreeWalker.h`
#include "Nodes.h" #include <functional> template <typename PreVisitAction, typename PostVisitAction> struct TreeWalker { PreVisitAction pre_visit; PostVisitAction post_visit; // 没有子节点(终结符)的节点的基本情况 void operator()(const NumberNode& node) { pre_visit(node); post_visit(node); } // 有一个子节点的节点情况 void operator()(const UnaryOpNode& node) { pre_visit(node); std::visit(*this, node.operand->var); // 递归 post_visit(node); } // 有两个子节点的节点情况 void operator()(const BinaryOpNode& node) { pre_visit(node); std::visit(*this, node.left->var); // 递归左侧 std::visit(*this, node.right->var); // 递归右侧 post_visit(node); } }; // 辅助函数,使创建遍历器更容易 template <typename Pre, typename Post> auto make_tree_walker(Pre pre, Post post) { return TreeWalker<Pre, Post>{pre, post}; }
这个`TreeWalker`是分离原则的杰作。它对打印、求值或类型检查一无所知。它唯一的目的是对树执行深度优先遍历,并调用提供的钩子。`pre_visit`动作以前序方式执行,`post_visit`动作以后序方式执行。通过选择实现哪个 lambda,用户可以执行任何类型的操作。
步骤 4:使用 `TreeWalker` 实现强大、解耦的操作
现在,让我们重构我们的`PrettyPrinter`并使用我们新的通用`TreeWalker`创建一个`EvaluationVisitor`。操作逻辑现在将表示为简单的 lambda 表达式。
为了在 lambda 调用之间传递状态(如求值栈),我们可以通过引用捕获变量。
文件:`main.cpp`
#include "Nodes.h" #include "TreeWalker.h" #include <iostream> #include <string> #include <vector> // 帮助器,用于创建可以处理任何节点类型的通用 lambda template<class... Ts> struct Overloaded : Ts... { using Ts::operator()...; }; template<class... Ts> Overloaded(Ts...) -> Overloaded<Ts...>; int main() { // 让我们为表达式构建一棵树:(5 + (10 * 2)) auto num5 = std::make_unique<Node>(Node{NumberNode{5.0}}); auto num10 = std::make_unique<Node>(Node{NumberNode{10.0}}); auto num2 = std::make_unique<Node>(Node{NumberNode{2.0}}); auto mult = std::make_unique<Node>(Node{BinaryOpNode{ BinaryOpNode::Operator::Multiply, std::move(num10), std::move(num2) }}); auto root = std::make_unique<Node>(Node{BinaryOpNode{ BinaryOpNode::Operator::Add, std::move(num5), std::move(mult) }}); std::cout << "--- 美观打印操作 ---\n"; auto printer_pre_visit = Overloaded { [](const NumberNode& node) { std::cout << node.value; }, [](const UnaryOpNode&) { std::cout << "(-"; }, [](const BinaryOpNode&) { std::cout << "("; } }; auto printer_post_visit = Overloaded { [](const NumberNode&) {}, // 什么也不做 [](const UnaryOpNode&) { std::cout << ")"; }, [](const BinaryOpNode& node) { switch (node.op) { case BinaryOpNode::Operator::Add: std::cout << " + "; break; case BinaryOpNode::Operator::Subtract: std::cout << " - "; break; case BinaryOpNode::Operator::Multiply: std::cout << " * "; break; case BinaryOpNode::Operator::Divide: std::cout << " / "; break; } } }; // 这将无法工作,因为子节点在 pre 和 post 之间被访问。 // 让我们改进遍历器,使其更灵活地进行中序打印。 // 更好的美观打印方法是拥有一个“in-visit”钩子。 // 为简单起见,让我们稍微重构打印逻辑。 // 或者更好,让我们创建一个专门的 PrintWalker。现在我们坚持 pre/post 并展示求值,这更适合。 std::cout << "\n--- 求值操作 ---\n"; std::vector<double> eval_stack; auto eval_pre_visit = [](const auto&){}; // 在预访问时什么也不做 auto eval_post_visit = Overloaded { [&](const NumberNode& node) { eval_stack.push_back(node.value); }, [&](const UnaryOpNode& node) { double operand = eval_stack.back(); eval_stack.pop_back(); eval_stack.push_back(-operand); }, [&](const BinaryOpNode& node) { double right = eval_stack.back(); eval_stack.pop_back(); double left = eval_stack.back(); eval_stack.pop_back(); switch(node.op) { case BinaryOpNode::Operator::Add: eval_stack.push_back(left + right); break; case BinaryOpNode::Operator::Subtract: eval_stack.push_back(left - right); break; case BinaryOpNode::Operator::Multiply: eval_stack.push_back(left * right); break; case BinaryOpNode::Operator::Divide: eval_stack.push_back(left / right); break; } } }; auto evaluator = make_tree_walker(eval_pre_visit, eval_post_visit); std::visit(evaluator, root->var); std::cout << "求值结果: " << eval_stack.back() << std::endl; return 0; }
看看这个求值逻辑。它非常适合后序遍历。我们只在子节点的值被计算并推入栈之后才执行操作。`eval_post_visit` lambda 捕获了`eval_stack`并包含了所有求值逻辑。这个逻辑与节点定义和`TreeWalker`完全分离。我们实现了关注点的出色三向分离:数据结构(节点)、遍历算法(`TreeWalker`)和操作逻辑(lambdas)。
通用访问者方法的优点
这种实现策略带来了显著的优势,尤其是在大型、长期维护的软件项目中。
无与伦比的灵活性和可扩展性
这是主要的好处。添加一个新操作是微不足道的。您只需编写一组新的 lambda 表达式并将其传递给`TreeWalker`。您无需触碰任何现有代码。这完美地遵循了开闭原则。添加新的节点类型需要添加结构体并更新`std::variant`别名——一个单一、局部化的更改——然后更新需要处理它的访问者。编译器会很好地告诉您现在哪些访问者(重载的 lambda)缺少一个重载。
卓越的关注点分离
我们已经隔离了三个不同的职责:
- 数据表示:`Node`结构体是简单的、惰性数据容器。
- 遍历机制:`TreeWalker`类独占地拥有如何导航树结构的逻辑。您可以轻松地创建`InOrderTreeWalker`或`BreadthFirstTreeWalker`,而无需更改系统的任何其他部分。
- 操作逻辑:传递给遍历器的 lambda 表达式包含给定任务的特定业务逻辑(求值、打印、类型检查等)。
这种分离使代码更容易理解、测试和维护。每个组件都有一个单一、明确定义的职责。
增强的可重用性
`TreeWalker`是无限可重用的。遍历逻辑只需编写一次,即可应用于无限数量的操作。这减少了代码重复以及由于在每个新访问者中重新实现遍历逻辑而可能出现的 bug。
简洁而富有表现力的代码
借助现代 C++ 特性,生成的代码通常比经典访问者实现更简洁。Lambda 表达式允许在操作逻辑使用的地方直接定义,这可以提高简单、局部操作的可读性。用于从一组 lambda 表达式创建访问者的`Overloaded`辅助结构体是一种常见而强大的惯用法,可以保持访问者定义的清晰。
潜在的权衡和考量
没有哪个模式是万能药。理解所涉及的权衡很重要。
初始设置复杂性
使用`std::variant`和通用`TreeWalker`的`Node`结构体的初始设置可能比直接的递归函数调用感觉更复杂。此模式在树结构稳定但操作数量预计随时间增长的系统中能带来最大收益。对于非常简单的一次性树处理任务,这可能有些杀鸡用牛刀。
性能
在 C++ 中使用`std::visit`实现此模式的性能非常出色。`std::visit`通常由编译器使用高度优化的跳转表来实现,使得分派速度极快——通常比虚函数调用更快。在其他可能依赖反射或基于字典的类型查找来实现类似泛型行为的语言中,与经典的静态分派访问者相比,可能会有明显的性能开销。
语言依赖性
这种特定实现的优雅性和效率严重依赖于 C++17 特性。虽然原理是可移植的,但其他语言中的实现细节会有所不同。例如,在 Java 中,现代版本可能会使用密封接口和模式匹配,而旧版本则可能使用更冗长的基于映射的分派器。
实际应用和用例
用于树遍历的通用访问者模式不仅仅是学术练习;它是许多复杂软件系统的支柱。
- 编译器和解释器:这是典型的用例。抽象语法树 (AST) 会被不同的“访问者”或“遍”多次遍历。语义分析遍检查类型错误,优化遍重写树以提高效率,代码生成遍遍历最终树以发出机器码或字节码。每个遍都是对同一数据结构的不同操作。
- 静态分析工具:诸如 linter、代码格式化器和安全扫描器等工具将代码解析为 AST,然后对其运行各种访问者以查找模式、强制执行样式规则或检测潜在漏洞。
- 文档处理 (DOM):当您操作 XML 或 HTML 文档时,您正在处理一棵树。通用访问者可用于提取所有链接、转换所有图像或将文档序列化为不同的格式。
- UI 框架:现代 UI 框架将用户界面表示为组件树。遍历此树对于渲染、传播状态更新(如 React 的协调算法)或分派事件是必要的。
- 3D 图形中的场景图:3D 场景通常表示为对象的层次结构。需要进行遍历以应用变换、执行物理模拟并将对象提交到渲染管道。一个通用遍历器可以应用渲染操作,然后重新用于应用物理更新操作。
结论:一个新层次的抽象
通用访问者模式,尤其是在使用专用`TreeWalker`实现时,代表了软件设计中的一个强大演进。它采纳了访问者模式的最初承诺——数据与操作的分离——并通过同时分离复杂的遍历逻辑来提升了这一承诺。
通过将问题分解为三个独立、正交的组件——数据、遍历和操作——我们构建了更模块化、可维护和健壮的系统。在不修改核心数据结构或遍历代码的情况下添加新操作的能力是软件架构的一项巨大胜利。`TreeWalker`成为一个可重用的资产,可以为数十种功能提供支持,确保遍历逻辑在所有使用的地方都是一致且正确的。
虽然它需要前期投入理解和设置,但通用树遍历访问者模式在项目整个生命周期中都会带来回报。对于任何处理复杂分层数据的开发人员来说,它是编写清晰、灵活和持久代码的基本工具。